SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
11.2 KB · 225 lines tsx
Raw Blame History
1import Link from "next/link";2import { notFound } from "next/navigation";3import { ArrowLeft, ArrowRight, KeyRound } from "lucide-react";4import { PLAN_LIMITS, normalizePlan } from "@fetcha/core";5import { getWorkspace } from "@/lib/session";6import { getOrgProject, listApiKeys, monthlyUsage } from "@/lib/queries/account";7import { formatDate, formatDateOnly, formatNumber, formatPercent, formatUsd, timeAgo } from "@/lib/format";8import { maskedKey } from "@/lib/account-snippets";9import { PageHeader, SectionTitle } from "@/components/ui/page-header";10import { Badge, StatusBadge } from "@/components/ui/badge";11import { Button } from "@/components/ui/button";12import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";13import { Stat, StatGrid } from "@/components/ui/stat";14import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";15import { EmptyState } from "@/components/ui/empty-state";16import { Alert } from "@/components/ui/alert";17import { ProjectForm } from "@/components/dashboard/projects/project-form";18import { ArchiveProjectButton, SelectProjectButton } from "@/components/dashboard/projects/project-actions";19import { CreateKeyDialog } from "@/components/dashboard/api-keys/create-key-dialog";2021export const dynamic = "force-dynamic";2223export default async function ProjectDetailPage({ params }: { params: Promise<{ id: string }> }) {24  const { id } = await params;25  const ws = await getWorkspace();26  const project = await getOrgProject(ws.organization.id, id);27  if (!project) notFound();2829  const [usage, keys] = await Promise.all([monthlyUsage(ws.organization.id, project.id), listApiKeys(ws.organization.id, project.id)]);30  const activeKeys = keys.filter((k) => k.status === "active");31  const current = ws.project.id === project.id;32  const archived = Boolean(project.archivedAt);33  const planLimits = PLAN_LIMITS[normalizePlan(ws.organization.plan)];34  const requestCap = project.monthlyRequestLimit ?? planLimits.monthly_requests;35  const requestPct = requestCap > 0 && requestCap < Number.MAX_SAFE_INTEGER ? Math.min(100, (usage.requests / requestCap) * 100) : null;36  const spendPct = project.hardLimitUsd ? Math.min(100, (usage.spendUsd / project.hardLimitUsd) * 100) : null;3738  return (39    <div className="grid gap-6">40      <div>41        <Link href="/dashboard/projects" className="inline-flex items-center gap-1 text-[12.5px] text-fg-muted underline-offset-4 hover:text-fg hover:underline">42          <ArrowLeft className="size-3.5" /> All projects43        </Link>44      </div>45      <PageHeader46        eyebrow={<span className="font-mono normal-case tracking-normal">{project.id}</span>}47        title={48          <span className="flex flex-wrap items-center gap-2">49            {project.name}50            <Badge variant={project.environment === "production" ? "success" : "warning"}>{project.environment}</Badge>51            {current ? (52              <Badge variant="accent" dot>53                Current54              </Badge>55            ) : null}56            {archived ? <StatusBadge status="archived" /> : null}57          </span>58        }59        description={project.description || `Created ${formatDate(project.createdAt)}.`}60        actions={61          <>62            {!current && !archived ? (63              <SelectProjectButton projectId={project.id} size="sm">64                Make current65              </SelectProjectButton>66            ) : null}67            <Button asChild size="sm" variant="outline">68              <Link href={`/dashboard/api-keys?project=${project.id}`}>69                <KeyRound /> API keys70              </Link>71            </Button>72          </>73        }74      />7576      {archived ? <Alert variant="warning" title="This project is archived">Its keys no longer authenticate and it is hidden from the project switcher. Contact support@fetcha.co to restore it.</Alert> : null}7778      <StatGrid cols={4}>79        <Stat label="Requests this month" value={formatNumber(usage.requests)} hint={requestPct !== null ? `${formatPercent(requestPct, 0)} of ${formatNumber(requestCap)}` : "no cap"} />80        <Stat label="Spend this month" value={formatUsd(usage.spendUsd)} hint={project.hardLimitUsd ? `hard limit ${formatUsd(project.hardLimitUsd)}` : "no hard limit"} />81        <Stat label="Active keys" value={formatNumber(activeKeys.length)} hint={`${keys.length - activeKeys.length} inactive`} />82        <Stat label="Created" value={formatDateOnly(project.createdAt)} mono={false} hint={`updated ${timeAgo(project.updatedAt)}`} />83      </StatGrid>8485      <div className="grid gap-6 lg:grid-cols-[1fr_320px]">86        <div className="grid gap-6">87          <Card>88            <CardHeader>89              <CardTitle>Project settings</CardTitle>90              <CardDescription>Defaults apply to requests that omit the field. Limits are enforced per calendar month.</CardDescription>91            </CardHeader>92            <CardContent>93              <ProjectForm94                mode="edit"95                projectId={project.id}96                initial={{97                  name: project.name,98                  description: project.description,99                  environment: project.environment,100                  defaultCountry: project.defaultCountry,101                  defaultNetwork: project.defaultNetwork,102                  logLevel: project.logLevel,103                  softLimitUsd: project.softLimitUsd,104                  hardLimitUsd: project.hardLimitUsd,105                  monthlyRequestLimit: project.monthlyRequestLimit,106                }}107              />108            </CardContent>109          </Card>110111          <section>112            <SectionTitle113              right={114                <Link href={`/dashboard/api-keys?project=${project.id}`} className="inline-flex items-center gap-1 text-[12.5px] text-fg-muted underline-offset-4 hover:text-fg hover:underline">115                  Manage keys <ArrowRight className="size-3.5" />116                </Link>117              }118            >119              API keys · {keys.length}120            </SectionTitle>121            {keys.length === 0 ? (122              <EmptyState123                compact124                icon={KeyRound}125                title="No keys for this project"126                description="Generate a key to call the API on behalf of this project."127                action={!archived ? <CreateKeyDialog projects={[{ id: project.id, name: project.name, environment: project.environment }]} defaultProjectId={project.id} /> : undefined}128              />129            ) : (130              <div className="overflow-hidden rounded-lg border border-border bg-bg-elevated shadow-xs">131                <Table>132                  <TableHeader>133                    <TableRow className="hover:bg-transparent">134                      <TableHead>Name</TableHead>135                      <TableHead>Key</TableHead>136                      <TableHead>Mode</TableHead>137                      <TableHead>Last used</TableHead>138                      <TableHead>Status</TableHead>139                    </TableRow>140                  </TableHeader>141                  <TableBody>142                    {keys.slice(0, 8).map((k) => (143                      <TableRow key={k.id} className={k.status !== "active" ? "text-fg-muted" : undefined}>144                        <TableCell className="font-medium text-fg">{k.name}</TableCell>145                        <TableCell>146                          <code className="font-mono text-[12.5px]">{maskedKey(k.keyPrefix, k.last4)}</code>147                        </TableCell>148                        <TableCell>149                          <Badge variant={k.mode === "live" ? "success" : "warning"}>{k.mode}</Badge>150                        </TableCell>151                        <TableCell className="tabular">{timeAgo(k.lastUsedAt)}</TableCell>152                        <TableCell>153                          <StatusBadge status={k.status} />154                        </TableCell>155                      </TableRow>156                    ))}157                  </TableBody>158                </Table>159                {keys.length > 8 ? (160                  <div className="border-t border-border px-4 py-2 text-[12.5px] text-fg-muted">161                    <Link href={`/dashboard/api-keys?project=${project.id}`} className="underline-offset-4 hover:underline">162                      View all {keys.length} keys163                    </Link>164                  </div>165                ) : null}166              </div>167            )}168          </section>169        </div>170171        <div className="grid gap-6 self-start">172          <Card>173            <CardHeader>174              <CardTitle>Spending limits</CardTitle>175              <CardDescription>Month-to-date against this project&apos;s limits.</CardDescription>176            </CardHeader>177            <CardContent className="grid gap-4 text-[13px]">178              <LimitRow label="Requests" value={`${formatNumber(usage.requests)} / ${requestCap < Number.MAX_SAFE_INTEGER ? formatNumber(requestCap) : "∞"}`} pct={requestPct} note={project.monthlyRequestLimit ? "project cap" : `${planLimits.label} — no monthly cap`} />179              <LimitRow label="Spend" value={`${formatUsd(usage.spendUsd)}${project.hardLimitUsd ? ` / ${formatUsd(project.hardLimitUsd)}` : ""}`} pct={spendPct} note={project.hardLimitUsd ? "hard limit" : "no hard limit"} />180              <div className="flex items-center justify-between border-t border-border pt-3 text-fg-muted">181                <span>Soft limit (alert)</span>182                <span className="font-mono tabular text-fg">{project.softLimitUsd ? formatUsd(project.softLimitUsd) : "—"}</span>183              </div>184              <p className="text-[12px] text-fg-subtle">185                Organization-wide limits live in{" "}186                <Link href="/dashboard/settings" className="underline-offset-4 hover:underline">187                  Settings188                </Link>189                .190              </p>191            </CardContent>192          </Card>193194          {!archived ? (195            <Card className="border-danger/30">196              <CardHeader>197                <CardTitle className="text-danger">Danger zone</CardTitle>198                <CardDescription>Archiving hides the project and disables its keys.</CardDescription>199              </CardHeader>200              <CardContent>201                <ArchiveProjectButton projectId={project.id} name={project.name} disabled={ws.projects.length <= 1} disabledReason="You need at least one active project." />202              </CardContent>203            </Card>204          ) : null}205        </div>206      </div>207    </div>208  );209}210211function LimitRow({ label, value, pct, note }: { label: string; value: string; pct: number | null; note: string }) {212  return (213    <div className="grid gap-1.5">214      <div className="flex items-center justify-between">215        <span className="text-fg-muted">{label}</span>216        <span className="font-mono tabular">{value}</span>217      </div>218      <div className="h-1.5 overflow-hidden rounded-full bg-bg-muted" role="progressbar" aria-valuenow={pct ?? undefined} aria-valuemin={0} aria-valuemax={100} aria-label={`${label} used`}>219        {pct !== null ? <div className={pct >= 90 ? "h-full bg-danger" : pct >= 70 ? "h-full bg-warning" : "h-full bg-accent"} style={{ width: `${pct}%` }} /> : null}220      </div>221      <div className="text-[11.5px] text-fg-subtle">{note}</div>222    </div>223  );224}225